Skip to content

feat: add Claude Code CLI as an LLM provider (no API key)#61

Closed
huanghe wants to merge 1 commit intonashsu:mainfrom
huanghe:claude/quirky-kilby-153ec5
Closed

feat: add Claude Code CLI as an LLM provider (no API key)#61
huanghe wants to merge 1 commit intonashsu:mainfrom
huanghe:claude/quirky-kilby-153ec5

Conversation

@huanghe
Copy link
Copy Markdown

@huanghe huanghe commented Apr 24, 2026

Summary

Adds a new LLM provider — Claude Code CLI (local) — that lets users with a Claude Code subscription pick Claude models in settings without pasting an Anthropic API key. The app spawns the local claude binary as a subprocess and streams the conversation over stdin/stdout as stream-json, so the OAuth credentials already in ~/.claude/ are reused and no key ever touches this app.

The transport sits behind the same StreamCallbacks contract as every HTTP provider, so the chat panel, cancellation (AbortSignal), and tokenized streaming all work identically — the rest of the code doesn't know which transport it's talking to.

What changed

Rust (Tauri backend) — new module src-tauri/src/commands/claude_cli.rs:

  • claude_cli_detect — locates claude on PATH, runs claude --version with a 3s timeout, returns {installed, version, path, error}. Surfaces the macOS Gatekeeper quarantine remediation hint (xattr -d com.apple.quarantine …) when that specific failure is detected.
  • claude_cli_spawn — spawns claude -p --output-format stream-json --input-format stream-json --verbose --model <model>, writes the serialized history to stdin (closing it so claude starts processing), and forwards each stdout line as a Tauri event claude-cli:{streamId}. stderr is collected and bundled into the final claude-cli:{streamId}:done event so non-zero exits surface the real diagnostic.
  • claude_cli_kill — cancels a running subprocess for AbortSignal support.
  • ClaudeCliStateHashMap<stream_id, Child> in a tokio Mutex; registered via .manage() in lib.rs.

TS (frontend) — new file src/lib/claude-cli-transport.ts:

  • createClaudeCodeStreamParser() — parses stream-json lines into token text. Handles both stream_event (real Anthropic-API deltas, passthrough when --verbose is on) and assistant events (full in-progress message on every emission). When both arrive, deltas are authoritative and the fat assistant events are suppressed to avoid double-render. When only assistant events arrive, emits the novel tail by prefix-diffing against what we've already shown.
  • streamClaudeCodeCli() — wires invoke/listen to the Rust commands, enforces the AbortSignal contract, and maps exit codes to user-facing errors. Sampling overrides (temperature/top_p/max_tokens/stop) have no CLI equivalents and are silently ignored with a dev-only console warning so callers don't silently wonder why they don't take effect.

Dispatchsrc/lib/llm-client.ts routes provider === "claude-code" to the subprocess transport before getProviderConfig, which explicitly throws for this provider since it has no URL/headers.

Settings UIllm-provider-section.tsx excludes claude-code from the needsApiKey check (no key field shown) and renders a ClaudeCliStatusPill that calls claude_cli_detect on mount so the user sees immediately whether the binary is installed, quarantined, or missing.

PresetLLM_PRESETS gets a claude-code-cli entry with defaultModel: "claude-sonnet-4-6" and the full Opus/Sonnet/Haiku lineup as suggestions. 200k context window.

Notable details for reviewers

  • content must be an array of blocks on both user and assistant turns. During end-to-end testing I hit W is not an Object. (evaluating '"tool_use_id"in W') the moment assistant history appeared — the CLI iterates content blocks looking for tool_use_id and crashes on a raw string. User-only single-turn tolerates a string (that's the trap), so the Rust code normalizes both roles to [{type:"text",text:...}].
  • System messages are inlined into the first user turn rather than passed via --system-prompt / --append-system-prompt, because flag availability varies across claude CLI versions. Inlining works on every version.
  • Why tokio::process directly, not tauri-plugin-shell — the plugin's scope model is designed for sidecars or fixed absolute paths; scoping a user-installed PATH binary cleanly is awkward. A hardcoded Rust command that always and only spawns claude provides the same security property (the webview can't call this command to execute anything else) without pulling in another plugin or editing capabilities/*.json.
  • Agent-mode state is explicitly out of scope — the CLI's tool use, MCPs, file-edit abilities, and --resume session state are all ignored. We use claude purely as a text-completion engine. Multi-turn history is reconstructed from the messages array on every call, symmetric with every other provider.
  • Cargo.toml adds tokio (features: process, io-util, sync, macros, rt), which = "7", and uuid = "1" with v4.

Test plan

  • Unit tests — src/lib/__tests__/claude-cli-transport.test.ts covers 9 parser scenarios (delta-only, assistant-only, delta-then-assistant dedupe, multi-part text, unknown event types, malformed JSON). All pass.
  • getProviderConfig({provider:"claude-code"}) throws — test added in llm-providers.test.ts.
  • tsc --noEmit clean on frontend.
  • cargo check clean (only pre-existing warnings in fs.rs).
  • Injection test: ran the exact command the Rust side spawns, confirmed single-turn and multi-turn-with-assistant-history both return valid responses ("Mango""Pineapple" for a fruit follow-up). This is how the content-must-be-array bug was found.
  • Manual smoke in the desktop app: install claude, open Settings, pick "Claude Code CLI (local)", send a message, verify streaming tokens render.
  • Manual cancel test: hit Stop mid-response, verify claude_cli_kill terminates the subprocess.
  • Manual macOS-quarantine test: if reachable, confirm the status pill surfaces the xattr remediation hint.

🤖 Generated with Claude Code

Users with a Claude Code subscription can now pick "Claude Code CLI
(local)" in settings and reuse their existing OAuth credentials instead
of pasting an Anthropic API key. The app spawns the local `claude`
binary as a subprocess, pipes the conversation in over stdin as
stream-json, and forwards stdout deltas back to the chat panel — same
StreamCallbacks contract as every HTTP provider, so the rest of the
app doesn't know which transport it's talking to.

Why subprocess instead of tauri-plugin-shell: the plugin's scope model
is designed for sidecars or fixed absolute paths; scoping a
user-installed PATH binary cleanly is awkward. A hardcoded Rust command
that always and only spawns `claude` provides the same security
property without pulling in another plugin or editing capabilities JSON.

Notable details:
- content must be an array of blocks ([{type:"text",text}]) on BOTH
  user and assistant turns. A raw string works for single-turn user
  input but crashes the CLI with `W is not an Object` once assistant
  history is present, because it iterates blocks looking for tool_use_id.
- System messages are inlined into the first user turn rather than
  passed via --system-prompt, since flag availability varies across
  CLI versions.
- Sampling knobs (temperature, top_p, max_tokens, stop) have no CLI
  equivalents and are silently ignored with a dev-only console warning.
- Settings UI shows a status pill that calls claude_cli_detect on
  mount to confirm the binary is on PATH and runnable, with a macOS
  Gatekeeper-quarantine remediation hint when that specific failure
  is detected.
- stderr is bundled into the final :done event so non-zero exits
  surface the real diagnostic ("exited with code 1: <stderr>") rather
  than just an opaque exit code.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@ZiXuanVickyLu
Copy link
Copy Markdown

Good feature. @nashsu, would you please give a review?

@nashsu nashsu closed this in 63d8538 Apr 25, 2026
@nashsu
Copy link
Copy Markdown
Owner

nashsu commented Apr 25, 2026

Merged manually onto main as commit 63d8538 after rebasing past the v0.3.11–v0.3.13 changes (RAG pipeline, RRF retrieval, context budget rework, Origin header fix, etc.). GitHub marks this PR as "closed" rather than "merged" because the rebase changed the commit SHA, but your authorship is preserved on the merged commit and you'll show up in the project's Contributors list.

Conflict resolution was minimal — just a couple of additions to the same invoke_handler! list in lib.rs and a TypeScript strict-mode tweak (SpawnPayload needed an index signature for Tauri's invoke typing). Tests are all green: 705 mocked + the new 9 parser scenarios you added.

Thanks for the careful design write-up and the security-conscious approach (hardcoded Command::new("claude"), stdin-based message passing, no credential touching). Appreciated. 🙏

@nashsu
Copy link
Copy Markdown
Owner

nashsu commented Apr 25, 2026

@ZiXuanVickyLu merged

@ZiXuanVickyLu
Copy link
Copy Markdown

Appreciated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants